← 返回首页

Profiling 实战解读:从采集、读数到把变慢钉到单个算子

拿这次 A3(昇腾)GLM-5.1 排查的真实文件讲——同一套服务的两个推理镜像:新的 1009、旧的 0815(按构建日期代称):怎么采、采完长什么样、kernel_details.csv 怎么读、怎么从“一堆 CSV”走到“DispatchFFNCombine prefill 慢 14%”的归因。

vLLM 源码 · 2026-10-16 发布 · vllm-ascend NPU Profiling 实战笔记

新手必读这篇文档是一份“从零看懂 Profiling”的实战笔记。第一次接触的读者,建议按以下顺序读:

💡 遇到不懂的术语,鼠标悬停在 虚线词 上可以看到解释。代码块可以左右滑动。

0. Profiling 能回答什么问题

排查的起点很朴素:同一套 GLM-5.1 服务换了推理镜像后变慢——新镜像(10-09 构建,下文简称 1009)比老镜像(08-15 构建,下文简称 0815)的 TTFT(首 token 时延,用户发出请求到收到第一个字)慢了 15% 至 20%,到底慢在哪个算子上?端侧指标(TTFT/吞吐)只能说明“慢了”,profiling 才能回答“慢在谁身上、慢多少、什么 shape 下慢”。这次它交出的答案:

为什么这些答案重要? 端侧只能看到“TTFT 慢了 15%”,但没法知道是哪个算子、什么形状、什么阶段慢。Profiling 就像给 NPU 装了一个“行车记录仪”,能把每一次 kernel 执行的时间、形状、设备都录下来,据此精确锁定瓶颈。

没有 profiling,这个故事到“TTFT 慢 15-20%”就断了。

1. 怎么采:vllm serve 里的 --profiler-config + 短窗口姿势

1 挂参数 2 短窗口采集 3 其他要点

1.1 开关:它是 serve 的独立 CLI 参数

vllm-ascend 采集 NPU profiling 不用改代码,启动时挂上 --profiler-config(注意:独立参数,不是 additional_config 里的字段——放错位置 pydantic 会报 unexpected keyword):

--profiler-config '{"profiler": "torch", "torch_profiler_dir": "/home/ywx1279670/profile", "torch_profiler_with_stack": false}'

1.2 短窗口姿势:这次最重要的一条经验

第一次全量采集直接干出 257GB,几乎导不动也析不动。后来固化成“压测中段 + 15 秒短窗口”的姿势,三个窗口 du -sh 实测 13GB / 14GB / 18GB(-93%),ASCEND 输出解析约 1.5 分钟:

采集姿势:压测拉起来 → 等流量稳定 → 中段开 12 至 15 秒窗口 压测开始 流量稳定(并发 8 打满) start 15s 后 stop 压测继续/结束 全量采(压测全程) 数据 257GB:拷不动、解析小时级、盘随时爆 这次踩过的坑 短窗口 12 至 15s 数据 13~18GB(-93%)· ASCEND 输出约 1.5 分钟出全 定位 kernel 级问题足够了 为什么中段采:压测刚起时流量在爬坡(请求没打满),中段稳态才代表真实负载;短窗口数据量小, 对比两版本时窗口条件也更容易对齐(同压测参数、同窗口长度、同 rank)。
图 1 · 短窗口采集姿势:257GB 教训 → 13~18GB(du -sh 实测:13 / 14 / 18GB)
为什么一定要中段采? 压测刚开始时流量还在爬坡,请求没打满,采集到的数据不代表真实负载。等流量稳定后再采,才能反映真实压力下的表现。短窗口则让数据量小到可以快速分析,同时更容易对齐两个版本的采集条件。

1.3 其他实操要点

2. 采完长什么样:目录结构与每个文件的用途

这次的真实路径与体积(du -sh / ls -lh 实测):

/home/ywx1279670/profile/
├── 1010_cc8_mlapo/                                        # 1009 decode 短窗口 · 18GB
├── 1010_cc8_prefill2/                                     # 1009 prefill 短窗口 · 14GB
│   └── dp0_pp0_tp0_dcp0_ep0_rank0_289383_20261010221318731_ascend_pt/
│       ├── ASCEND_PROFILER_OUTPUT/                        # ★ 自动解析结果,分析主要就看这里
│       │   ├── kernel_details.csv            209M         # ★ 每个 kernel 的每次执行一行(659657 行)
│       │   ├── ascend_pytorch_profiler_0.db  744M         # 原始时序库(最大的一块)
│       │   ├── task_time.csv                 138M
│       │   ├── communication.json             85M         # 通信时序(看重叠时用)
│       │   ├── operator_details.csv           31M         # host 侧算子明细
│       │   ├── analysis.db                   8.1M
│       │   ├── api_statistic.csv             8.1K        # host 侧 API 统计(很小,直接看)
│       │   ├── op_statistic.csv              4.7K        # ★ 按算子聚合(很小,直接看)
│       │   ├── communication_matrix.json     10K
│       │   ├── step_trace_time.csv          295B         # 每个 step 的计算/通信拆解(一行一条)
│       │   └── analyse.done                  0B          # 解析完成标记
│       └── PROF_*/ host_info.log / device_*.log           # 原始二进制与日志(一般不动)
├── 1010_cc8_0815_prefill/                                 # 0815 prefill 短窗口(对照组)· 13GB
│   └── dp0_pp0_tp0_dcp0_ep0_rank0_165753_20261010232101886_ascend_pt/
│       └── ASCEND_PROFILER_OUTPUT/  (kernel_details.csv 200M · 636120 行,结构同上)
└── parsed_r0/k10_rank0.csv                                # 早期一次采集的解析结果(留存对比用)

两个细节:①目录名里带 dp0_pp0_tp0_dcp0_ep0_rank0(并行布局与 rank)和采集时刻时间戳(如 20261010221318731),对比两份数据前先对时间戳与 rank;②这套工具链导出里没有 profiler_info.json / CHROME_TRACE,不同版本文件清单略有差异,以实际 ls 为准。

文件粒度什么时候用它
kernel_details.csvdevice 上每个 kernel 每次执行一行主力文件:按 kernel 名聚合、看分布(P50/P99)、按 shape 分阶段,全靠它
op_statistic.csv按算子聚合后的统计快速看“谁总耗时最大”,但看不到分布和 shape 细节,定位长尾必须回 kernel_details
step_trace_time.csv每个 step 一行看计算/通信拆解与重叠(本例全文只有一行,见下方实际数据);没分窗口采时靠它分阶段
api_statistic.csvhost 侧 API 聚合统计,几 KB看 CPU 侧谁在烧时间(tiling、拷贝);与 op_statistic 分工:它看 host,op_statistic 看 device
communication.json / communication_matrix.json通信时序 / 矩阵看通信与计算重叠、卡间流量;本例 85M,按需加载
解析工具怎么选(本次实测)数据落在 ASCEND_PROFILER_OUTPUT 是自动的(torch 路线导出即解析,约 1.5 分钟);torch_npu.analyse 接口也可以手动触发;msprof --parse=on 我们试过一次失败——优先用自动产物,别恋战。

3. kernel_details.csv 怎么读:三板斧

1 列含义 2 实际数据 3 分析流程 4 pandas 模板

3.1 列含义(本例真实表头)

真实表头(两版本一致):

Device_id, Model ID, Task ID, Stream ID, Name, Type, OP State, Accelerator Core,
Start Time(us), Duration(us), Wait Time(us), Block Num, Mix Block Num, HF32 Eligible,
Input Shapes, Input Data Types, Input Formats, Output Shapes, Output Data Types, Output Formats,
Context ID, aicore_time(us), aic_total_cycles, aic_mac_time
列本例的样子分析时怎么用
NameDispatchFFNCombine、aclnnQuantMatmulWeightNz_QuantBatchMatmulV3_...聚合维度;str.contains 锁目标算子
Duration(us)如 3963.123(小数,微秒)核心指标:sum 看总量、分位数看分布
Input Shapes"220,6144;8,6144,4096;8,2048,6144;..."本案例的关键钥匙:按它分组才能拆出 prefill 大 chunk 调用
Start Time(us)纳秒级时间戳(如 1791641598803750.295)切窗口、看时序(是否与通信重叠)
Accelerator CoreAI_CORE / AI_VECTOR_CORE / AI_CPU / MIX_AIC区分 cube / vector / cpu kernel; MIX_AIC = 融合大算子
Device / Stream / Task ID0 / 47 / 43982多卡多流时区分归属;单 rank 分析基本不用管

3.2 文件里的实际数据(原样摘录)

kernel_details.csv 前 2 行数据(每行截断,真实内容)——开头都是些 1~3us 的小 kernel:

0,4294967295,43982,47,aclnnInplaceFillScalar_FillAiCore_Fill,Fill,dynamic,AI_VECTOR_CORE,1791641598803750.295,1.560,0,1,0,NO,"""1;""",INT64;INT32,ND;ND,"""9""",INT32,ND,...
0,4294967295,43987,47,aclnnIndex_IndexCheck_IndexCheck,IndexCheck,dynamic,AI_VECTOR_CORE,1791641598804008.502,3.120,256.690000,4,0,NO,"""1;4""",INT64;INT64,ND;ND,N/A,N/A,N/A,...

op_statistic.csv(4.7KB,全文可直接看)前几行——30 秒摸底就看它:DispatchFFNCombine 一家占 40.5%:

Device_id,OP Type,Core Type,Count,Total Time(us),Min Time(us),Avg Time(us),Max Time(us),Ratio(%)
0,DispatchFFNCombine,MIX_AIC,12007,4574397.762,204.794,380.978,14539.16,40.534
0,QuantBatchMatmulV3,MIX_AIC,54036,1378404.569,9.162,25.509,290.756,12.214
0,SparseFlashAttention,MIX_AIC,12469,1284798.358,93.464,103.039,938.124,11.385
0,TransposeBatchMatMul,AI_CORE,24939,549558.977,18.224,22.036,85.243,4.87
0,allgatherAicpuKernel,AI_CPU,249,467424.672,279.793,1877.208,65305.219,4.142
0,MatMulV2,AI_CORE,17212,407542.289,11.863,23.678,223.238,3.611
0,reduce_scatterAicpuKernel,AI_CPU,171,172385.056,246.744,1008.1,5012.683,1.528

step_trace_time.csv(295B,全文就一行)——这个窗口计算为主、通信大半被重叠:

Device_id,Step,Computing,Communication(Not Overlapped),Overlapped,Communication,Free,Stage,Bubble,...
0,,10604437.507,  1318080.581,  38628.661,  1356709.242,  451331.072,  12373849.25, 0,...

读法:窗口总时长约 13.9s,其中 Computing 10.6s(76%),不重叠通信 1.32s(9.5%),Overlapped 仅 38.6ms——说明通信几乎都被计算盖住了,瓶颈在 kernel 执行本身,这也是后面敢把矛头指向单算子耗时的依据之一。

api_statistic.csv 前几行——host 侧视角,看 CPU 侧谁在烧时间:

Device_id,Level,API Name,Time(us),Count,Avg(us),Min(us),Max(us),Variance
host,communication,AivKernel,157403.46,6271,25.1,18.48,70.74,10.974
host,acl,Cast_Tiling,20853.01,1232,16.926,5.18,313.13,289.435
host,acl,ForeachAddList_Tiling,34145.23,1674,20.397,0.63,767.48,1125.884

3.3 分析流程

① 全局排行 按 kernel 名 sum 排序 谁总耗时最大 ② 锁算子看分布 count / mean / P50 / P99 P99 差大=长尾问题 ③ 按 shape 分阶段 220~222×6144=prefill 1~4 行=decode ④ 两版本对齐比 同 kernel 同 shape 0815 vs 1009 逐名对比 ⑤ 归因 算子包/代码 ①②回答“谁慢”,③回答“什么条件下慢”,④⑤回答“为什么慢”——本次按此顺序走通
图 2 · kernel_details.csv 分析五步:排行 → 分布 → shape → 对比 → 归因
为什么不能只看均值? 均值会把“偶尔很慢”和“一直有点慢”混在一起。比如这次,全窗口池化后 1009 的 p50 反而更低,因为窗口里混了大量 ~300us 的 decode 小调用,把 prefill 大 chunk 的变慢稀释了。所以必须看 p99(长尾)并且按 shape 拆开。

3.4 pandas 模板(这次分析脚本的骨架,列名按真实文件写)

import pandas as pd, glob

# ---- 取文件:递归找 ASCEND_PROFILER_OUTPUT 下的 kernel_details.csv ----
f = glob.glob("/home/ywx1279670/profile/1010_cc8_prefill2/**/kernel_details.csv",
              recursive=True)[0]
df = pd.read_csv(f)          # 本例 209MB / 659657 行,读入约 1~2 分钟

dur, name, shp = "Duration(us)", "Name", "Input Shapes"

# ---- ① 全局排行:谁总耗时最大(等价于看 op_statistic.csv,但可算分位数)----
top = (df.groupby(name)[dur]
         .agg(cnt="count", total="sum", mean="mean",
              p50=lambda s: s.quantile(.5), p99=lambda s: s.quantile(.99))
         .sort_values("total", ascending=False).head(20))
print(top)

# ---- ② 锁定目标算子,看分布 ----
k = df[df[name].str.contains("DispatchFFNCombine", na=False)]
print(len(k), k[dur].mean(), k[dur].quantile(.5), k[dur].quantile(.99))

# ---- ③ 按 Input Shapes 分组(★关键:不按 shape 拆开看不到真差距)----
k = k.assign(shapes=k[shp].astype(str))
g = (k.groupby("shapes")[dur]
       .agg(cnt="count", mean="mean",
            p50=lambda s: s.quantile(.5), p99=lambda s: s.quantile(.99))
       .sort_values("cnt", ascending=False))
print(g.head(6))

# ---- ④ 两版本对齐对比:同一套聚合分别跑 0815 / 1009 目录后按 shapes 拼表 ----
# cmp = g0815.join(g1009, lsuffix="_0815", rsuffix="_1009")
# 只看“大 chunk”那几行(shapes 里首维 220~222),p50/p99 逐行对比

服务器上当时的分析脚本(/tmp/analyze_tri2.py 三边对比、/tmp/deep_analyse.py shape/重叠深挖)就是这个骨架加上多目录循环和输出整理。下面的实战复盘全部是按这个模板跑出来的真实数字。

4. 实战复盘:从两份 CSV 到“FFNCombine 大 chunk 回退”

按 3.4 的模板把两份 prefill 窗口各跑一遍,下面所有表都是脚本真实输出(本次复核重跑)。

1 全局排行 2 看分布 3 按 shape 拆 4 结论

4.1 第一步:全局排行——两版本 top10(按总耗时)

1009(1010_cc8_prefill2,窗口 kernel 总耗时 13.8s)0815(1010_cc8_0815_prefill,15.7s)
kernel次数总ms占比kernel次数总ms占比
DispatchFFNCombine120074574.433.1%DispatchFFNCombine102184772.330.4%
QuantBatchMatmulV3(NZ)659701511.010.9%QuantBatchMatmulV3(NZ)559621651.610.5%
SparseFlashAttention124691284.89.3%reduce_scatter4001489.09.5%
AivKernel783821174.78.5%SparseFlashAttention106111267.88.1%
TransposeBatchMatMul24939549.64.0%allgather4121070.46.8%
allgather249467.43.4%AivKernel34714724.94.6%
MatMulV217212407.52.9%MatMulV225283562.73.6%
AscendQuantV241104356.12.6%AscendQuantV234977303.41.9%
ForeachAddList12008213.61.5%ScatterNdUpdate10611279.11.8%
reduce_scatter171172.41.2%BatchMatMulV210611220.61.4%

排行本身就有信息量:1009 的集合通信便宜了很多(reduce_scatter 1489→172ms、allgather 1070→467ms,这是 1009 通信优化的收益),FFNCombine 总耗时反而还低了(4772→4574ms,调用次数还多 17%)。单看总量排名,根本发现不了回退——这正是第二步要按分布、第三步要按 shape 拆的原因。

4.2 第二步:锁 DispatchFFNCombine 看分布(全窗口池化)

### DispatchFFNCombine 对比(全窗口池化)
0815 {'cnt': 10218, 'mean': 467.0, 'p50': 304.8, 'p99': 3971.4, 'sum_s': 4.77}
1009 {'cnt': 12007, 'mean': 381.0, 'p50': 276.3, 'p99': 4116.7, 'sum_s': 4.57}

池化口径下 1009 的 p50 / mean 反而更低——因为窗口里混着两类调用:~300us 的 decode 形状小调用(1~3 token)和 3~4ms 的 prefill 大 chunk 调用,两个窗口里两类调用的比例不一样(1009 窗口小调用 11622 次 / 0815 窗口 9672 次),直接池化会被稀释甚至比反。必须按 Input Shapes 拆开。

这里最容易掉坑: 如果只看到池化后的数字(p50 更低),可能会错误地认为 1009 更快。但真相是:池化把两种完全不同的调用(decode 小调用 vs prefill 大 chunk)混在一起,而两个窗口里它们的比例不同,导致统计结果被“稀释”甚至“比反”。所以一定要按 Input Shapes 分组。

4.3 第三步:按 Input Shapes 分组(真实输出,本案例的转折点)

### 1009 prefill(按 shape 分组,Duration(us))
shapes                                                        cnt   mean     p50      p99
1,6144;8,6144,4096;...(decode 形状)                        9672   276.8   275.7    334.0
2,6144;8,6144,4096;...(decode 形状)                        1950   276.6   274.5    385.2
220,6144;8,6144,4096;...(prefill 大 chunk)                  234  4117.6  3972.5  11088.4
221,6144;8,6144,4096;...(prefill 大 chunk)                  151  2613.4  3710.6   4819.7

### 0815 prefill
shapes                                                        cnt   mean     p50      p99
2,6144;8,6144,4096;...(decode 形状)                        7020   303.6   303.8    334.4
3,6144;8,6144,4096;...(decode 形状)                        2652   307.5   305.3    435.7
221,6144;8,6144,4096;...(prefill 大 chunk)                  468  3249.2  3436.9   5544.6
222,6144;8,6144,4096;...(prefill 大 chunk)                   78  3909.8  3623.0   8034.6
🔍 拆开立刻现形:
DispatchFFNCombine P50 对比(真实数据,按 Input Shapes 分组) [221,6144] · 0815 3436.9 us [221,6144] · 1009 3710.6 us +8% [220,6144] · 1009 3972.5 us(p99 11088) [220,6144] · 0815 本窗口无该 shape 调用(0815 的 chunk 以 221/222 为主) decode 形状 [2,6144] · 0815 303.8 decode 形状 [1,6144] · 1009 275.7 —— decode 无回退 条长按 us 等比;数据来自两份 kernel_details.csv 按 Input Shapes 分组的真实统计。
图 3 · 同算子按 shape 拆开后的真实对比:大 chunk +8%、长尾 p99 11088us、decode 无回退
步骤做了什么看到什么结论推进
窗口准备0815 / 1009 各采 15s prefill 窗口(同压测参数:输入 3.5K+输出 1.5K、32 请求、并发 8)两份 ..._ascend_pt/ASCEND_PROFILER_OUTPUT/(14GB / 13GB)可比对的前提成立
① 排行两份各跑 groupby total 排行FFNCombine 均为第一(30~33%);通信算子 1009 明显变便宜;FFNCombine 总耗时看不出回退排除 decode 窗口嫌疑;总量口径失效,转分布
② 分布锁 DispatchFFNCombine 看 P50/P99(池化)p50 反而 304.8→276.3——池化被两类调用比例稀释池化口径失效,转 shape 分组
③ shape按 Input Shapes 分组统计[221,6144] 3436.9→3710.6(+8%);[220,6144] p99 11088;decode 形状无回退锁定“prefill 大 chunk 路径”,decode 无关
④ 干扰排除step_trace 看重叠、shape 同分布检查Computing 76% / 不重叠通信 9.5%,大 chunk 调用与 decode 通信无重叠排除调度/重叠干扰,慢是“纯执行”慢
⑤ 归因对比两版本 CANN 自定义算子包libcust_opapi.so / libcust_opsproto_rt2.0.so / binary_info_config.json / dispatch_ffn_combine_bf16.json 的 staticKey 全部 DIFF0815→1009 间算子包更新了 kernel 实现 → 提单算子团队
交叉验证别忘了端侧指标profiling 结论要能解释端侧:TTFT 差距约 15-20%,而 DispatchFFNCombine 占窗口 kernel 总耗时的 30~40%、大 chunk 调用慢 8~14%(池化口径),两者量级吻合;后来 enable_prefill_mc2 开关验证 TTFT 持平(753-768 vs 739-759)、mlapo(MLA 前处理融合开关)无收益,也都与“瓶颈在这个融合算子实现”自洽。profiling 证据 + 开关实验 + 端侧指标三方对上,结论才算闭环。

5. 坑与经验清单(都是这次交过学费的)

姊妹篇:《算子与 vLLM 参数:一条链讲透》——本篇的 profiling 数据反过来用:从算子清单反推启动配置,参数影响算子的四种方式。

注:文中目录名、文件体积、行数与全部统计数字(257GB / 13~18GB / 659657 行 / op_statistic 占比 40.5% / [221,6144] P50 3436.9→3710.6us / [220,6144] p99 11088us / staticKey DIFF 等)为本次排查实测与复核重跑的真实输出;当年报告口径为 220~222 池化 P50 3469→3963us(+14%),两者方向一致、幅度随分组口径略有差异。

华杉的技术博客 · 苏ICP备2026063680号-1
首页